昨天我們談了自主決策的邊界。今天是這個系列最後一塊技術拼圖:記憶。
回想 Day 18 講過的一件事:
API 是無狀態的。模型完全不記得上一次的對話。
它之所以「記得」,是因為我們把歷史又傳了一次。
但這帶來兩個問題:
今天要解決這兩件事。
我把 Agent 的記憶分成三種,它們的生命週期和用途都不一樣:
| 類型 | 存什麼 | 活多久 | 放哪裡 |
|---|---|---|---|
| 工作記憶 | 這次任務的中間結果 | 一次任務 | messages 陣列 |
| 短期記憶 | 最近的對話 | 一次 session | 記憶體 + 存檔 |
| 長期記憶 | 使用者的偏好與重要事實 | 永久 | JSON 檔 / 資料庫 |
用人來比喻:
每一輪都要重傳全部歷史。假設每輪平均 200 tokens:
| 輪次 | 輸入 tokens | 累積 |
|---|---|---|
| 1 | 200 | 200 |
| 5 | 1,000 | 3,000 |
| 10 | 2,000 | 11,000 |
| 20 | 4,000 | 42,000 |
**成本是平方成長的。**聊到第 20 輪,光是輸入就燒了 4 萬多 token。
最簡單——只保留最近 N 則:
"""memory/conversation.py"""
import json
import logging
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
class ConversationMemory:
"""管理對話歷史。"""
def __init__(self, max_messages=20, session_id=None):
self.max_messages = max_messages
self.session_id = session_id or datetime.now().strftime("%Y%m%d_%H%M%S")
self.messages = []
def add_user(self, content):
self.messages.append({"role": "user", "content": content})
self._trim()
def add_assistant(self, content):
self.messages.append({"role": "assistant", "content": content})
self._trim()
def _trim(self):
"""保留最近 max_messages 則,並確保格式合法。"""
if len(self.messages) <= self.max_messages:
return
self.messages = self.messages[-self.max_messages:]
# API 要求第一則必須是 user
while self.messages and self.messages[0]["role"] != "user":
self.messages.pop(0)
logger.debug("對話歷史已裁切至 %d 則", len(self.messages))
def get(self):
return list(self.messages)
def clear(self):
self.messages = []
**注意 _trim() 裡那個 while 迴圈。**API 要求 messages 必須從 user 開始,而且 user / assistant 要交替。單純切最後 N 則可能切出一個以 assistant 開頭的陣列,就會報錯。
這是實作滑動視窗時最常見的 bug。
當歷史太長時,把舊的對話請模型摘要成一段文字:
SUMMARIZE_PROMPT = """以下是一段對話記錄。請摘要成 150 字以內的重點,保留:
- 使用者提到的個人資訊、偏好、習慣
- 已經完成或約定的事項
- 還沒解決的問題
用第三人稱陳述,例如「使用者住在台北,不吃牛肉」。
不要加任何開場白,直接輸出摘要。"""
class SummarizingMemory(ConversationMemory):
"""歷史太長時自動摘要壓縮。"""
def __init__(self, client, max_messages=20, keep_recent=6, **kwargs):
super().__init__(max_messages=max_messages, **kwargs)
self.client = client
self.keep_recent = keep_recent
self.summary = None
def _trim(self):
if len(self.messages) <= self.max_messages:
return
# 把舊的部分拿出來摘要
to_summarize = self.messages[:-self.keep_recent]
recent = self.messages[-self.keep_recent:]
transcript = "\n".join(
f"{'使用者' if m['role'] == 'user' else '助理'}:{self._text(m)}"
for m in to_summarize
)
if self.summary:
transcript = f"【先前的摘要】\n{self.summary}\n\n【新的對話】\n{transcript}"
response = self.client.messages.create(
model="claude-haiku-4-5", # 摘要用便宜的模型就好
max_tokens=512,
system=SUMMARIZE_PROMPT,
messages=[{"role": "user", "content": transcript}],
)
self.summary = "".join(
b.text for b in response.content if b.type == "text"
).strip()
logger.info("已壓縮 %d 則對話為摘要", len(to_summarize))
self.messages = recent
while self.messages and self.messages[0]["role"] != "user":
self.messages.pop(0)
@staticmethod
def _text(message):
content = message["content"]
if isinstance(content, str):
return content
# content 可能是 block list(工具呼叫的情況)
return " ".join(
b.get("text", f"[{b.get('type')}]")
for b in content if isinstance(b, dict)
)
def get_context(self):
"""回傳要注入 system prompt 的摘要。"""
if not self.summary:
return ""
return f"\n\n## 先前對話摘要\n{self.summary}"
兩個實務重點:
**用便宜的模型做摘要。**摘要是簡單任務,用 Haiku 就好,成本差五倍。
**_text() 要處理 block list。**當對話包含工具呼叫時,content 不是字串而是 list。直接拿去串接會噴錯,這個坑我踩過。
短期記憶解決「這次對話」的問題。長期記憶解決「這個人是誰」。
"""memory/profile.py"""
import json
import logging
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
PROFILE_PATH = Path("data/user_profile.json")
DEFAULT_PROFILE = {
"name": None,
"city": "臺北市",
"facts": [], # 關於使用者的事實
"preferences": {}, # 偏好設定
"updated_at": None,
}
class UserProfile:
"""使用者的長期記憶。"""
def __init__(self, path=PROFILE_PATH):
self.path = Path(path)
self.data = self._load()
def _load(self):
profile = dict(DEFAULT_PROFILE)
if not self.path.exists():
return profile
try:
saved = json.loads(self.path.read_text(encoding="utf-8"))
profile.update(saved) # Day 10 講過的合併技巧
except json.JSONDecodeError:
logger.warning("使用者檔案損壞,使用預設值")
return profile
def save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self.data["updated_at"] = datetime.now().isoformat(timespec="seconds")
self.path.write_text(
json.dumps(self.data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# --- 事實 ---
def remember_fact(self, fact: str, category: str = "general"):
"""記住一件關於使用者的事。"""
fact = fact.strip()
if not fact:
return False
# 避免重複
for existing in self.data["facts"]:
if existing["content"] == fact:
existing["mentioned_count"] += 1
existing["last_mentioned"] = datetime.now().isoformat(timespec="seconds")
self.save()
return False
self.data["facts"].append({
"content": fact,
"category": category,
"created_at": datetime.now().isoformat(timespec="seconds"),
"last_mentioned": datetime.now().isoformat(timespec="seconds"),
"mentioned_count": 1,
})
self.save()
logger.info("記住新事實:%s", fact)
return True
def forget_fact(self, keyword: str):
before = len(self.data["facts"])
self.data["facts"] = [
f for f in self.data["facts"] if keyword not in f["content"]
]
self.save()
return before - len(self.data["facts"])
# --- 偏好 ---
def set_preference(self, key, value):
self.data["preferences"][key] = value
self.save()
def get_preference(self, key, default=None):
return self.data["preferences"].get(key, default)
# --- 給模型看的格式 ---
def to_context(self, max_facts=15) -> str:
"""轉成可以放進 system prompt 的文字。"""
lines = []
if self.data.get("name"):
lines.append(f"- 稱呼:{self.data['name']}")
if self.data.get("city"):
lines.append(f"- 所在地:{self.data['city']}")
# 依提及次數排序,最常提到的放前面(Day 16 的 sorted)
facts = sorted(
self.data["facts"],
key=lambda f: (-f["mentioned_count"], f["created_at"]),
)[:max_facts]
for fact in facts:
lines.append(f"- {fact['content']}")
for key, value in self.data["preferences"].items():
lines.append(f"- {key}:{value}")
if not lines:
return ""
return (
"\n\n## 關於這位使用者\n"
+ "\n".join(lines)
+ "\n\n請自然地運用這些資訊,不要刻意提起你記得這些事。"
)
to_context() 產生的東西長這樣:
## 關於這位使用者
- 稱呼:pzhiqi
- 所在地:臺北市
- 正在參加 iThome 鐵人賽,主題是 AI Agent
- 不吃牛肉
- 習慣晚上運動
- 通勤方式:機車
- reminder_time:08:00
請自然地運用這些資訊,不要刻意提起你記得這些事。
最後那句話很重要。沒有的話,模型會一直說「我記得你說過你不吃牛肉」,很刻意也很煩。
最漂亮的做法:把記憶做成工具。
"""tools/memory_tool.py"""
from tools.base import Tool, ToolError
class RememberTool(Tool):
name = "remember"
tags = ("memory", "write")
description = (
"記住一件關於使用者的長期事實,未來的對話都會記得。"
"適合記錄:飲食限制、通勤方式、作息習慣、家庭成員、"
"長期目標、對助理的偏好。"
"不要記錄:一次性的待辦事項(請用 add_todo)、"
"臨時的資訊、可以隨時查到的東西(天氣、日期)。"
"只在使用者明確透露個人資訊時使用,不要過度記錄。"
)
input_schema = {
"type": "object",
"properties": {
"fact": {
"type": "string",
"description": (
"用第三人稱陳述的一句話,例如「使用者不吃牛肉」、"
"「使用者習慣晚上七點後運動」。要具體、簡短。"
),
},
"category": {
"type": "string",
"enum": ["飲食", "作息", "工作", "家庭", "偏好", "其他"],
"description": "這件事的分類",
},
},
"required": ["fact"],
}
def __init__(self, profile):
self.profile = profile
def run(self, fact, category="其他"):
is_new = self.profile.remember_fact(fact, category)
if is_new:
return f"已記住:{fact}"
return f"這件事我已經記得了:{fact}"
class RecallTool(Tool):
name = "recall"
tags = ("memory",)
description = (
"查詢關於使用者已經記住的資訊。"
"當使用者問「你還記得我…嗎」,"
"或你需要確認某件個人資訊時使用。"
)
input_schema = {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "要查詢的關鍵字,留空則列出全部",
}
},
}
def __init__(self, profile):
self.profile = profile
def run(self, keyword=None):
facts = self.profile.data["facts"]
if keyword:
facts = [f for f in facts if keyword in f["content"]]
if not facts:
return f"沒有找到關於「{keyword}」的記憶。" if keyword else "目前還沒有記住任何事。"
return "目前記得:\n" + "\n".join(
f"- {f['content']}({f['category']})" for f in facts
)
class ForgetTool(Tool):
name = "forget"
tags = ("memory", "write")
dangerous = True # 需要確認(Day 25)
description = (
"刪除關於使用者的某項記憶。"
"只在使用者明確要求忘記某件事時使用。這個操作無法復原。"
)
input_schema = {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "包含這個關鍵字的記憶會被刪除",
}
},
"required": ["keyword"],
}
def __init__(self, profile):
self.profile = profile
def run(self, keyword):
count = self.profile.forget_fact(keyword)
if count == 0:
raise ToolError(f"沒有找到包含「{keyword}」的記憶")
return f"已忘記 {count} 件關於「{keyword}」的事。"
實際體驗:
你:我不吃牛肉,之後推薦餐廳的時候注意一下
🔧 使用工具 remember({'fact': '使用者不吃牛肉', 'category': '飲食'})
小幫:好的,我記下來了。之後推薦的時候會避開牛肉料理。
--- 隔天重新啟動程式 ---
你:晚餐吃什麼好?
小幫:看你想吃什麼類型。日式的話烏龍麵或鮭魚定食,
中式可以吃個雞肉飯,都是輕食一點的選擇。
你:你怎麼沒推薦牛肉麵?
小幫:因為你之前說過不吃牛肉,所以我沒列進來。
它記得,而且是自然地運用,不是每句話都提醒你「我記得你說過」。
ForgetTool 標記了 dangerous = True——刪除記憶是不可逆的,照 Day 25 的原則要先確認。
"""assistant.py — 有記憶的助理。"""
import anthropic
import config
import prompts
from memory.conversation import SummarizingMemory
from memory.profile import UserProfile
from tools import ToolBox
from tools.datetime_tool import DateTimeTool
from tools.memory_tool import ForgetTool, RecallTool, RememberTool
from tools.todo_tool import AddTodoTool, ListTodosTool
from tools.weather_tool import WeatherTool
from agent import Agent
from agent.budget import Budget
from agent.confirm import ConfirmationPolicy
class Assistant:
"""整合記憶、工具與 Agent 的完整助理。"""
def __init__(self):
self.client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
self.profile = UserProfile()
self.conversation = SummarizingMemory(self.client, max_messages=20)
self.toolbox = ToolBox([
DateTimeTool(),
WeatherTool(default_city=self.profile.data.get("city")),
AddTodoTool(),
ListTodosTool(),
RememberTool(self.profile),
RecallTool(self.profile),
ForgetTool(self.profile),
])
self.policy = ConfirmationPolicy(mode="ask")
def build_system_prompt(self):
"""每次都重新組裝,因為記憶會變。"""
return (
prompts.AUTONOMOUS_SYSTEM
+ self.profile.to_context()
+ self.conversation.get_context()
)
def chat(self, user_input, confirm_fn=None):
agent = Agent(
system_prompt=self.build_system_prompt(),
toolbox=self.toolbox,
)
answer, _ = agent.run(
user_input,
history=self.conversation.get(),
budget=Budget(max_turns=8, max_cost_usd=0.30),
policy=self.policy,
confirm_fn=confirm_fn,
)
self.conversation.add_user(user_input)
self.conversation.add_assistant(answer)
return answer
注意 build_system_prompt() 是每次對話都重新組裝的。
為什麼?因為記憶會變——如果模型剛剛呼叫了 remember,下一輪的 system prompt 就應該包含那件新記住的事。
但這也有代價:system prompt 一變,快取就失效了。
所以實務上要權衡:
這個系列的規模不大,我選簡單的做法。
記憶會進到每一次的 system prompt。記 100 件事,每次對話就多 1000+ tokens。
我的做法:
to_context(max_facts=15) 限制數量# ❌ 太籠統
"使用者喜歡運動"
# ✅ 具體
"使用者習慣晚上七點後在住家附近跑步,每次約 30 分鐘"
# ❌ 一句話塞太多(要改其中一項時很麻煩)
"使用者不吃牛肉、住台北、騎機車通勤、習慣晚上運動"
# ✅ 拆開
"使用者不吃牛肉"
"使用者住在台北"
"使用者騎機車通勤"
這是隱私的基本要求。RecallTool 讓使用者可以問「你記得我什麼」,ForgetTool 讓他可以刪除。
而且檔案是明文 JSON,使用者隨時可以自己打開看、自己改。不要把記憶藏在使用者看不到的地方。
健康狀況、財務細節、他人的個資——除非使用者明確要求,否則不要主動記錄。
可以在工具的 description 裡明講:
"不要記錄:健康狀況、財務資訊、密碼、他人的個人資料。"
使用者的狀況會變。「我不吃牛肉」可能三個月後就變了。所以:
created_at,讓舊記憶可以被識別回顧一下我們蓋了什麼:
┌─────────────────────────────────────────┐
│ Assistant(整合層) │
│ ┌──────────┐ ┌──────────┐ │
│ │ 記憶 │ │ Agent │ │
│ │ ・工作記憶 │ │ ・迴圈 │ │
│ │ ・短期記憶 │ │ ・預算 │ Day 21-26 │
│ │ ・長期記憶 │ │ ・確認 │ │
│ └──────────┘ └──────────┘ │
│ ┌─────────────────────────┐ │
│ │ Workflow(固定流程) │ Day 23-24 │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ Tools(能力層) │ Day 15,22 │
│ │ 天氣 / 待辦 / 時間 / 記憶 │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ LLM Client / Prompt │ Day 18-20 │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ 基礎設施 │ Day 8-17 │
│ │ 設定 / HTTP / 日誌 / 分析 │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ Python 基礎 │ Day 2-7 │
│ └─────────────────────────┘ │
└─────────────────────────────────────────┘
接下來三天(Day 27–29),我們要把這些零件組裝成一個完整、可以每天實際使用的生活助理。
user
content 是 block list 的情況to_context() 轉成 system prompt 的一部分明天開始最後的整合專案:需求拆解與系統設計。